Don't block the event loop on sync resource and prompt functions#2380
Merged
Don't block the event loop on sync resource and prompt functions#2380
Conversation
PR #1909 fixed this for tools by running sync functions via anyio.to_thread.run_sync, but the same blocking pattern existed in FunctionResource.read, ResourceTemplate.create_resource, and Prompt.render. All three called self.fn() directly and checked inspect.iscoroutine(result) afterward, so a blocking sync @mcp.resource or @mcp.prompt handler would still freeze the event loop. This applies the same fix: check inspect.iscoroutinefunction(self.fn) up front and dispatch sync functions to a worker thread. Verified that pydantic.validate_call (used to wrap stored functions in templates and prompts) preserves async-ness, so the check works correctly on the wrapped function. Github-Issue: #1646
Proves the behavioral fix directly: the handler blocks on a threading.Event in a worker thread while the async side awaits an anyio.Event. The handler signals back into the event loop via anyio.from_thread.run_sync, so the async side's await resolves without polling or sleeps. On regression (sync runs inline), anyio.from_thread.run_sync raises RuntimeError immediately since there is no worker-thread context, failing fast rather than waiting out the fail_after timeout.
Kludex
approved these changes
Mar 31, 2026
Member
Kludex
left a comment
There was a problem hiding this comment.
I'm not sure those tests are useful. I would prefer to not have tests in this case.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extends the #1909 fix to resources and prompts — sync
@mcp.resourceand@mcp.prompthandlers now run in worker threads instead of blocking the event loop.Motivation and Context
#1909 fixed tools by routing sync functions through
anyio.to_thread.run_sync, but the same blocking pattern existed in three other places:FunctionResource.read()ResourceTemplate.create_resource()Prompt.render()All three called
self.fn(...)directly and checkedinspect.iscoroutine(result)afterward. A blocking sync handler (file I/O, HTTP request, CPU-bound work) would freeze the entire event loop.This applies the same fix: check
inspect.iscoroutinefunction(self.fn)up front and dispatch sync functions to a worker thread.Related: #1646, #1839
How Has This Been Tested?
Added thread-identity regression tests for each of the three call sites. Each test captures
threading.get_ident()inside a sync handler and asserts it differs from the event loop's thread.Verified that
pydantic.validate_call(which wraps the storedself.fnin templates and prompts) preserves async-ness —inspect.iscoroutinefunction(validate_call(async_fn))returnsTrue, so the dispatch check works correctly on the wrapped function.All 39 tests in the affected test files pass; pyright, ruff, and
strict-no-coverare clean.Breaking Changes
None. Sync handlers that were previously starving the event loop now run concurrently.
Types of changes
Checklist
Additional context
The previous implementation happened to support callable objects with async
__call__(by checkingiscoroutine(result)after calling). That edge case is not preserved here — matching the approach taken in #1909, which relies on_is_async_callablebeing evaluated at registration time for tools. Resources and prompts have no equivalent pre-computed field; if that edge case matters, it's worth a separate discussion.AI Disclaimer